Skip to content

add host passphrase entry and typed device states - #137

Open
benma-agent wants to merge 5 commits into
BitBoxSwiss:masterfrom
benma-agent:benma-agent/host-passphrase
Open

benma-agent wants to merge 5 commits into
BitBoxSwiss:masterfrom
benma-agent:benma-agent/host-passphrase

Conversation

@benma-agent

Copy link
Copy Markdown
Contributor

Add configurable host passphrase entry for firmware v9.28.0 and later,
supporting automatic entry or an optional host-entry button. Prompt for the
passphrase only after device approval, and keep legacy unlocking for older
firmware.

Introduce PairedBitBox::{Locked, Unlocked, Uninitialized} so wallet operations
are only available on UnlockedBitBox. This changes wait_confirm() to return
the appropriate state; initialized devices on newer firmware use an explicit
LockedBitBox::unlock() after pairing.

Includes copied firmware protobufs, simple CLI and Cursive examples, and
separate U2F fixes for the final continuation frame, payload size checking,
and encoding error propagation.

The sequence counter is incremented after writing each continuation
frame. After frame 127 it becomes 128, causing the encoder to reject a
valid completed message. This affects payloads of 7,551 to 7,609 bytes.

Only report too many frames if there is still payload left to encode.
The encoder casts message lengths to u16 before checking the required
buffer size. Lengths above u16::MAX wrap, making that check unreliable;
other oversized messages are only rejected later by buffer or frame limits.

Reject payloads above the 7,609-byte U2F HID limit before narrowing the
length or writing any frames.
U2fHidCommunication::write unconditionally unwraps the framing result.
If a message is too large for U2F HID, encoding returns an error and the
library panics instead of returning a transport error.

Add U2fEncode and propagate encoding failures through ReadWrite::write.
Introduce a PairedBitBox enum with locked, unlocked and uninitialized
variants backed by a private session. Expose wallet operations only on
UnlockedBitBox, and consume UninitializedBitBox when restoring a wallet.

Keep device entry and unlock before pairing on every firmware version.
Validate the legacy unlock response to distinguish an unlocked wallet from
an uninitialized device. Reserve LockedBitBox::unlock for a later transition;
the connection flow cannot return that state yet.

Adapt existing examples and simulator helpers to the typed states.
@benma
benma requested a review from Beerosagos October 13, 2026 10:43
@benma
benma marked this pull request as ready for review October 13, 2026 10:43
@benma

benma commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

This is a breaking API change, but this library is on version 0., so it's expected by semver policy. I think the change is important to support the new passphrase in sane way and also to make the UnitializedBitBox methods type-safe (before they were just in the PairedBitBox struct, so they and the regular api fnctions could be called in the wrong state)

Use paired protobuf unlock on firmware v9.28.0 and later, returning
LockedBitBox after pairing and completing the transition through
LockedBitBox::unlock. Keep legacy device entry on older firmware.

Add PassphraseConfig with asynchronous input and optional availability
callbacks. Integrations can offer a host-entry button while device entry
is available, or request host entry automatically. Invoke the input callback
only after approval on the device. Expire stale request handles and reset
the session when unlocking fails.

Copy the firmware protobuf definitions and regenerate the Rust bindings.
Add a simple automatic CLI example and a Cursive example with a host button.
@benma-agent
benma-agent force-pushed the benma-agent/host-passphrase branch from 205c76e to 7f503d8 Compare October 13, 2026 10:58

@beerosagos-agent beerosagos-agent left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 0663f04..7f503d8 for correctness and security, followed by a separate simplification pass. The inline comments cover one confirmed API regression and two non-blocking simplification nits. I found no confirmed security flaw in the changed paths.

Validation passed: 47 normal unit tests, 46 multithreaded unit tests, two compile-fail doctests, and four device tests across all 11 configured firmware versions. Formatting and example compilation also passed. I reproduced the setup regression on the v9.27.1 simulator and tested the simplification candidates in a temporary copy, including all legal U2F payload lengths, undersized buffers, oversized inputs, both threading configurations, and device tests. The v9.28 host-entry flow was checked against firmware source and unit tests, without a hardware run.

Comment thread src/lib.rs
/// Show recovery words on the Bitbox.
pub async fn show_mnemonic(&self) -> Result<(), Error> {
let _api_call = self.begin_api_call().await;
let _api_call = self.session.begin_api_call().await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve the setup form of show_mnemonic()

Moving this operation exclusively onto UnlockedBitBox removes a valid setup flow. Firmware also accepts ShowMnemonic in its Seeded state: the seed has been created, but its backup has not yet been completed. If another client starts setup and the device is then reconnected here without being powered off, wait_confirm() returns UninitializedBitBox, which has no method to show those recovery words and finish initialization. The previous public API could perform this operation.

I reproduced this on the v9.27.1 simulator: create a seed with SetPassword, reconnect with initialized=false, then successfully send ShowMnemonic through the private session. The device reports initialized=true afterward. The firmware supports the operation; the new public Rust type prevents calling it.

Please expose the setup form on UninitializedBitBox, transitioning to UnlockedBitBox after successful confirmation. This preserves the typed-state design while retaining the firmware's valid setup transition.

Comment thread src/unlock.rs

impl<R: Runtime> Session<R> {
async fn unlock(&self, config: &PassphraseConfig) -> Result<(), Error> {
let _api_call = self.begin_api_call().await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking simplification: omit the mutex acquisition in consuming transitions

The only caller of this helper is LockedBitBox::unlock(self, ...), which consumes the device. The session is private and cannot have concurrent callers at that point, so this lock cannot contend. The same applies to the lock in UninitializedBitBox::restore_from_mnemonic(self).

Removing these two acquisitions preserves errors, cancellation cleanup, protocol timing, and thread safety; the locks are immediately available under the existing ownership guarantees. Keep serialization for operations accepting shared references, where concurrent calls are possible.

I tested removal in a temporary copy with both threading configurations and device tests; all passed, including unlock error/cancellation and future-Send coverage.

Comment thread src/u2fframing.rs

seq += 1;
if seq > 127 {
if seq > 127 && !message.is_empty() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking simplification: remove the now-unreachable continuation-count check

The new MAX_PAYLOAD_LEN check at the start of encode() already limits the payload to 7,609 bytes: 57 bytes in the initial frame plus at most 128 continuation frames of 59 bytes. Consequently, when seq reaches 128, no payload can remain, and this branch cannot be taken.

Removing it preserves accepted payloads and error behavior: oversized messages still fail the initial validation, and insufficient output buffers still fail the existing buffer-length check. No protocol, compatibility, or security behavior changes.

I verified the simplified encoder with round trips for every legal payload length, undersized output buffers for each length, and oversized lengths including 65,535, 65,536, and 65,537 bytes.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants